Skip to content

CI-260 - Transfer dataset files concurrently - #224

Merged
sminot merged 7 commits into
mainfrom
claude/upload-download-performance-61b806
Aug 24, 2026
Merged

CI-260 - Transfer dataset files concurrently#224
sminot merged 7 commits into
mainfrom
claude/upload-download-performance-61b806

Conversation

@sminot

@sminot sminot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Purpose

Speed up dataset upload and download. Transfer time previously scaled with the number of files rather than total bytes, because every file paid a large fixed cost and files were transferred strictly one at a time.

How

  1. Transfer files concurrently. upload_directory and download_directory now resolve the work list, then run several transfers at once through a ThreadPoolExecutor, reporting into one aggregate progress bar instead of one bar per file.
  2. Reuse the transfer manager. upload_fileobj/download_file each built and tore down a boto3 TransferManager (~16 threads) per file. S3Client now holds one for its lifetime and shuts it down when the batch ends. Local files are uploaded by filename so s3transfer reads their parts in parallel; Path-like objects that are not local files (see Change S3 Client upload implementation to use upload_fileobj #115) are streamed through their own open() into the same shared manager.
  3. Stop rebuilding the S3 client per file. DataPortalFiles.download called download_files once per file, constructing a fresh boto3.Session each time (~300 ms measured). It now makes a single batched call. File objects are also passed through instead of being flattened to strings, which drops one HeadObject per file.
  4. --threads to control it. Available on upload, download and resume-upload, and as a threads parameter on the service and SDK methods. Default 8. --threads 1 restores fully sequential behaviour: no ThreadPoolExecutor, and TransferConfig(use_threads=False) so boto3 runs the transfer inline too — which also makes this usable where threads are unavailable, such as Pyodide.

Behaviour changes

  • An upload that exhausts its retries now raises, naming every failed file. It previously returned normally, so a partially-uploaded dataset looked complete.
  • Retry backoff is capped at 60s (was uniform(0,60) + retry*60, up to ~45 min and blocking every remaining file).
  • Two source files mapping to the same destination path now raise instead of racing.

Biggest risk

Concurrency is the new default, so upgrading changes behaviour for everyone. 8 files in flight could surface throttling or contention on networks and tenants unlike the one tested here, and failures that were previously serial and deterministic are now interleaved. Mitigation is --threads N, with --threads 1 restoring the old sequential behaviour.

The risk I expected and ruled out: switching upload_fileobjupload_file could have changed checksum semantics. It does not (verified below).

Test dataset

301 files, 11.17 MB, uploaded to dev.cirro.bio / Pipeline Development as data type Files (custom_dataset):

  • 300 x 4 KB random-byte files across 5 subdirectories — the many-small-files case where per-file overhead dominates
  • 1 x 10 MB file, crossing boto3's 8 MB multipart threshold

Both arms ran on the same venv and interpreter (Python 3.14.6, boto3 1.41.5, s3transfer 0.15.0), with PYTHONPATH selecting which cirro loaded, so the only variable is this diff. Released 1.12.1's file_utils.py, clients/s3.py, sdk/file.py and services/file.py are byte-identical to this branch's merge-base, so the baseline is exactly "this branch minus these changes". 2 reps each, interleaved.

Results

Operation released 1.12.1 this branch speedup
Upload (CLI + SDK path) 37.5s, 33.0s 8.36s, 7.65s 4.4x
Download, datasets.download_files (CLI path) 51.9s, 47.0s 5.64s, 5.96s 8.5x
Download, dataset.download_files (SDK path) 136.1s, 140.1s 5.31s, 5.58s 25.4x

The SDK download path gains most because it also carried the per-file client rebuild: subtracting actual transfer time leaves ~90s of pure overhead on the baseline, or ~296 ms per file.

Correctness

  • Checksums unchanged. Uploading identical bytes with both versions produced the same ChecksumType=FULL_OBJECT and identical CRC64NVME values, for both the 4 KB file and the 10 MB multipart-threshold file.
  • Round trip clean. 301/301 files, 0 size mismatches, 0 CRC mismatches, 11,714,560 bytes.
  • validate_folder: 301 matching, 0 not-matching, 0 missing, 0 errors.
  • Peak threads plateaued at 24-25 (vs 7-8 baseline), matching 8 workers + 10 request + 5 submission + 1 io, and returned to baseline after each batch.
  • Zero Connection pool is full warnings across all 12 runs.
  • --threads verified end to end. threads=1 created zero worker threads and round-tripped 12/12 files; threads=8 created 8 and round-tripped 12/12. Neither left threads behind.

All benchmark datasets were deleted and the project reports none remaining.

Note on --threads 1

It removes every thread the transfer logic creates, but tqdm spawns its own monitor thread on the first progress bar. That is pre-existing (the released version does it too) and not affected by this flag.


🤖 Generated with Claude Code

Transfer time scaled with the number of files rather than total bytes,
because each file was transferred one at a time and paid a large fixed
cost before any bytes moved.

- Run several file transfers at once, reporting into one aggregate
  progress bar rather than one bar per file.
- Share a single boto3 transfer manager per client instead of building
  and tearing one down (~16 threads) for every file, and shut it down
  when the batch ends.
- Upload by filename rather than by open file object, so s3transfer
  reads multipart chunks in parallel instead of serially.
- Batch DataPortalFiles.download into one call; it previously rebuilt a
  boto3 session per file. Pass File objects through so their known sizes
  reach the transfer, saving a request per file.
- Add --threads (default 8) to upload, download and resume-upload, and a
  threads parameter on the service and SDK methods. threads=1 is fully
  sequential with no threads anywhere, including inside boto3.

An upload that exhausts its retries now raises and names every failed
file, where it previously returned normally and left a partial dataset
looking complete. Retry backoff is capped rather than growing to tens of
minutes and blocking the remaining files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sminot
sminot requested a review from nathanthorpe August 19, 2026 21:44
sminot and others added 3 commits August 19, 2026 14:54
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reduces its cognitive complexity from 23 to 4 by separating the three
concerns it had combined: retrying a single transfer, running the set
either sequentially or concurrently, and reporting the failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nathanthorpe nathanthorpe changed the title Transfer dataset files concurrently CI-260 - Transfer dataset files concurrently Aug 21, 2026
Comment thread cirro/clients/s3.py
self._client.download_file(bucket, key, absolute_path,
Callback=ProgressPercentage(progress),
ExtraArgs=self._download_args)
def upload_file(self, file_path: Path, bucket: str, key: str,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should preserve the "PathLike" behavior if possible, maybe we can detect if its a local file

sminot and others added 2 commits August 21, 2026 20:47
Uploading by filename lets s3transfer read a file's parts in parallel, but
it only works for real filesystem paths. #115 had switched to open()/
upload_fileobj precisely so Path-like objects backed by something else,
such as an s3fs path, could be uploaded too.

Check whether the path names a local file and pick accordingly: local
files go through the shared transfer manager, anything else is streamed
through its own open() as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file object path was going through client.upload_fileobj, which builds
a transfer manager and its thread pools per file - the churn the shared
manager was meant to remove, and worse once several files upload at once.

s3transfer's manager accepts a filename or a seekable file object, so both
paths can use one. Build it with create_transfer_manager and wrap it in
S3Transfer for the filename case, keeping boto3's error translation, and
submit file objects to the manager directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@sminot
sminot merged commit 947ec0a into main Aug 24, 2026
10 checks passed
@sminot
sminot deleted the claude/upload-download-performance-61b806 branch August 24, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants